値渡し・参照渡しを詳しく見てみる

今回のお題目は関数呼び出し時の引数の取り方を、アセンブリレベルで見てみることです。 アセンブリが読めないと何言ってるのか解らないかもしれません。 では行ってみましょう。
  1. Introduction
  2. 引数の取り方を比較してみる
    1. 値渡し (add1)
    2. 参照渡し (add2)
    3. C++ 参照渡し (add3)
  3. 呼び出し部分のコードを見てみる
  4. 結論
  5. その他の相違点
  6. まとめ

Introduction

 まずは、関数に渡す引数の渡し方について、どんなものがあるか見てみましょう。
 中・上級者向けとか言っておきながらこんなこと書いたら怒られますねw ちなみに、『ポインタによる参照渡し』は別の名前があった気がしますが、 忘れたので個人的表現をさせてもらいました。
 ところで、このうち 3 つめの『C++ の参照渡し』とありますが、 これはピュアな C では使うことができません。 そもそもこの参照渡しとは何なのかを、まずは追っていきましょう。

引数の取り方を比較してみる

 次のコードは渡された 2 値を加算して返す、代表的な無駄な関数です。 まずはこいつらを最適化なしでコンパイルして、 コードをはき出させてみます。
int __cdecl add1(int a, int b)
{
	return a + b;
}

int __cdecl add2(int *a, int *b)
{
	return *a + *b;
}

int __cdecl add3(int &a, int &b)
{
	return a + b;
}

値渡し (add1)


push        ebp
mov         ebp,esp
sub         esp,40h
push        ebx
push        esi
push        edi
lea         edi,[ebp-40h]
mov         ecx,10h
mov         eax,0CCCCCCCCh
rep stos    dword ptr [edi]
mov         eax,dword ptr [ebp+8]
add         eax,dword ptr [ebp+0Ch]
pop         edi
pop         esi
pop         ebx
mov         esp,ebp
pop         ebp
ret
 灰色の部分はコンパイラが適当にはき出した部分だから割愛して、 黒字の部分だけ追っていきます。 といってもこれは問題ないですね。 プロローグコードは私にはイミフですが、 とりあえず ebp の指すアドレス +8 の位置から eax にコピーし、 さらに ebp+12 の位置から eax に加算しています。 この中身はというと呼び出すために push した値です。 単純に eax に値をセットして終了です。

参照渡し (add2)

push        ebp
mov         ebp,esp
sub         esp,40h
push        ebx
push        esi
push        edi
lea         edi,[ebp-40h]
mov         ecx,10h
mov         eax,0CCCCCCCCh
rep stos    dword ptr [edi]
mov         eax,dword ptr [ebp+8]
mov         eax,dword ptr [eax]
mov         ecx,dword ptr [ebp+0Ch]
add         eax,dword ptr [ecx]
pop         edi
pop         esi
pop         ebx
mov         esp,ebp
pop         ebp
ret
 さて、ポインタによる参照渡しです。黒色のコードが少しだけ長くなっています。 ポインタは実際に処理すべき値のアドレスを指しています。 ので、まずはそのアドレスを読み込まなければなりません。 mov eax, [ebp+8] で push された引数を読み込んでいます。ここで、eax はポインタです。 そして、mov eax, [eax] で、eax に中の値を読み込んでいます。 [レジスタ] とすると、そのレジスタがポインタのようになり、中の値を引っ張ってくることができます。 同じように、ecx にポインタを読み込み、eax に間接的に加算しています。 結果、2 クロック命令が増えました。 この位は微々たる差ですがね。

C++ 参照渡し (add3)

push        ebp
mov         ebp,esp
sub         esp,40h
push        ebx
push        esi
push        edi
lea         edi,[ebp-40h]
mov         ecx,10h
mov         eax,0CCCCCCCCh
rep stos    dword ptr [edi]
mov         eax,dword ptr [ebp+8]
mov         eax,dword ptr [eax]
mov         ecx,dword ptr [ebp+0Ch]
add         eax,dword ptr [ecx]
pop         edi
pop         esi
pop         ebx
mov         esp,ebp
pop         ebp
ret
 最後は参照渡しです。 ってあれ、ポインタによる参照渡しと同じジャマイカ!! これではいけないので、今度は呼び出し部分のコードを見てみます。

呼び出し部分のコードを見てみる

 つーわけで、呼び出し部分のコードです。
int n, a, b;

a = 4;
b = 6;
n = add1(a, b);
n = add2(&a, &b);
n = add3(a, b);
 まずは add2 のアセンブリコードです。 言い忘れましたが、関数の呼び出しにはあえて明示的に __cdecl をつけてます。 これは、呼び出し側でスタック管理をするというおまじないで、 呼び出された関数に余計なコードを増やさないためにつけておきました。 なくてもデフォルトは __cdecl ですけどね。
lea         edx,[ebp-0Ch]
push        edx
lea         eax,[ebp-8]
push        eax
call        @ILT+25(add2) (0040101e)
add         esp,8
mov         dword ptr [ebp-4],eax
 続いて add3 のコード。
lea         ecx,[ebp-0Ch]
push        ecx
lea         edx,[ebp-8]
push        edx
call        @ILT+15(add3) (00401014)
add         esp,8
mov         dword ptr [ebp-4],eax
 っておい!使ってるレジスタは違えど、やってることは同じじゃないか!!
 そうですね、長々とコード使って追ってきましたが、この例だと差がわかりませんね。 そもそも、今回の検証に使った add2 のポインタによる参照渡しのところに非があったのです。 それは、次の章で詳しく見てみましょう。

結論

 早い話、ポインタ渡しはポインタの値を引数としてとるのに対し、 C++ の参照渡しは実行アドレスを引数としてとるのです。 この二つの最大の違いは、前者は整数値を直接指定できるのに対し、 後者はそれができません。 例えば、add2 の例で、
n = add2((int*) 0x10000000, (int*) 0x40000000);
 などとしてもエラーにはなりませんよね? もちろん、このまま実行すると高い確率でアクセス違反が起きてしまいますが。 つまり、ポインタだろうがなんだろうが、値は値ということです。 皆さん、ポインタの勉強をしたときに、『ポインタだって変数だ!』と参考書に散々言われ、 頭を抱えたのではないでしょうか? ポインタだって値なので、キャストしてやればこのようにちゃんと呼べるわけです。
 対して、参照渡しは違います。確かに、アセンブリレベルで見ると値のアドレスを渡しているように見えます。 ですが、これはポインタではなく、実行アドレスを渡しているのです。 今、これを書いている私も頭が混乱してきましたが、lea edx, [ebp-8] とかしてる部分が、 実際のアドレスをとってくるコードです。 つまり、参照渡しはアドレスを渡しているのではなく、 『値があるところを渡している』ということになります。
 ところで、参照渡しには値を直接指定できません。ポインタの時のようにキャストしてもできません。 しかし、これは考えてみれば至極当たり前のことです。 それは、即値が実行アドレスを持たないためだと思われます(実際にはプログラムコード内にあることはあるが)。 実行アドレスを欲しているのに、持たないものを渡そうとしてもケチつけられて当然です。 このように、ポインタ渡しと参照渡しには明確な差があるのです。
 ちなみに、上で add2 に非がある、と言いましたが、 add2 を呼ぶときのコードにも、lea eax, [ebp-8] というコードが見受けられます。 これは、変数の実行アドレスをとってきている、つまり、結果的に参照渡しになってしまっているということです。

その他の相違点

 最後に、ポインタ渡し(いつの間にかこの呼び方になってる)と参照渡しの相違点について考えてみましょう。
 まず、ポインタ渡しですが、呼び出し先の関数で
int addx(int *a, int *b)
{
	if( a != NULL && b != NULL ) {
		return *a + *b;
	} else {
		return 0;
	}
}
 のように、引数が正しく渡されたかチェックすることはよくあることですよね。 しかし、毎回これでは面倒です。 ポインタ渡しの最大の欠陥は、 ポインタの中身が保証されていないというところにあります。 対して、参照渡しでこのようなチェックをすることはありません。 なぜなら、実行アドレスを引数にとっているからです。 実行アドレスは必ず存在しているので、チェックする意味はないのです。
 なら、参照渡しが最強じゃないかと思うかもしれませんが、 参照渡しにも欠点があります。 それは、値を渡せない、というところです。 先ほどから何度となく言っていますが、 例えば、ポインタ渡しの場合、必要ない引数には NULL を指定することで、 その呼び出しでは値を取得しない、といったテクニックが使えます。 しかし、参照渡しではそれが使えません。NULL を指定したくても、これは整数値なので指定できません。 必ず一つ以上の実行アドレスを持つメモリを渡す必要があるからです。 また、C++ から使えるようになったデフォルト引数を指定するときにもやっかいです。 即値は使えませんから、グローバル変数か、クラスならメンバ変数を指定する位しかできません。 どちらも一長一短ということが解っていただけたでしょうか?

まとめ

 本日の講義のまとめです。
 参考までに、次のコードを見てください。
class Cake {
public:
	virtual void func() { printf("Cake\n"); }
};

class MontBlanc : public Cake {
public:
	virtual void func() { printf("MontBlanc\n"); }
};

void func01(Cake c)
{
	c.func();
}

void func02(Cake *c)
{
	c->func();
}

void func03(Cake &c)
{
	c->func();
}

void main()
{
	MontBlanc c;

	func01( c );
	func02( &c );
	func03( c );
}

 func01 〜 func03 を適切に呼び出してみます。 Cake は仮想関数 func を持っており、MontBlanc は Cake を継承し、func をオーバーライドしています。 よって、普通に考えると func メソッドを呼んだときは、『MontBlanc』が表示されるはずです。 ですが、上の場合、func02 と func03 は正しく『MontBlanc』が表示されますが、 func01 は『Cake』が表示されてしまいます。 これは、値渡しによって実態のコピーが渡されることが原因です。 おそらく、値をコピーするとき、メモリの都合か何なのか判りませんが、 Cake 分しかコピーしないのでしょう。 MontBlanc の仮想部分がコピーされないので、func01 に渡った c はただの Cake でしかありません。 逆に、func02, func03 は実行アドレスが渡され、そこには MontBlanc の情報があるため、 適切に仮想関数機能が働くというわけです。 クラスを使う場合は、極力値渡しはしないほうがいいでしょう。


[戻る]

[トップへ戻る]